OpenAPI 스키마를 계약으로 유지하는 방법

OpenAPI 스키마를 계약으로 유지하는 방법

한눈에 보기

스키마에서 클라이언트 타입과 문서를 생성하고 CI에서 breaking change를 검사한다. 서버의 런타임 검증도 같은 스키마와 연결한다.

목차

문제가 되는 상황

Wiki에는 name이 필수라고 적혀 있는데 실제 서버는 누락을 허용하고, TypeScript client는 email: string이라고 생성했지만 운영 응답에서 null이 내려오는 일이 생긴다. 문서, 서버 validation, client type이 각각 따로 관리되면 셋 중 하나는 곧 오래된다.

OpenAPI를 Swagger 화면을 띄우기 위한 설명 파일로만 두면 이 문제를 해결하지 못한다. 요청·응답·오류·인증·호환성 경계를 기계가 읽을 수 있는 계약으로 만들고, 코드 생성과 테스트, CI diff가 같은 계약을 바라보게 해야 한다.

이 글의 예제에 관하여

사용자 API와 schema는 OpenAPI 운영 방법을 설명하기 위해 만든 가상 예제다. 실제 프로젝트 DTO나 내부 DB 컬럼을 사용하지 않았다.

설명서와 계약의 차이

설명서는 사람이 현재 동작을 이해하도록 돕는다. 계약은 server와 client가 지켜야 할 입력·출력 범위를 명시하고 위반을 검출할 수 있어야 한다.

flowchart TD
    O[OpenAPI Contract] --> D[Reference Docs]
    O --> C[Client Types / SDK]
    O --> V[Request Validation]
    O --> T[Contract Tests]
    O --> B[Breaking Change Diff]

한 파일에서 모두 자동 생성한다고 정확성이 보장되는 것은 아니다. server 구현이 schema와 다르게 응답하면 generated client도 잘못된 전제를 갖는다. 생성과 함께 runtime 검증 또는 contract test가 필요하다.

작은 endpoint를 완전히 기술해 보기

사용자 한 명을 조회하는 endpoint에도 path parameter, 성공 응답, 없는 사용자, 인증 실패가 있다.

paths:
  /users/{userId}:
    get:
      operationId: getUser
      summary: 사용자 한 명을 조회한다
      security:
        - bearerAuth: []
      parameters:
        - name: userId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: 사용자 조회 성공
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/User"
        "401":
          $ref: "#/components/responses/AuthenticationRequired"
        "404":
          $ref: "#/components/responses/UserNotFound"

operationId는 client 함수명으로 사용될 수 있으므로 안정적이고 고유하게 정한다. description: success처럼 의미 없는 설명보다 어떤 상태에서 반환되는 응답인지 적는다.

재사용 가능한 schema는 components에 둔다.

components:
  schemas:
    User:
      type: object
      required:
        - id
        - displayName
        - createdAt
      properties:
        id:
          type: string
          format: uuid
        displayName:
          type: string
          minLength: 1
          maxLength: 100
        avatarUrl:
          type: [string, "null"]
          format: uri
        createdAt:
          type: string
          format: date-time
          readOnly: true

예시 값과 format은 문서 품질을 높이지만 server가 실제로 검증하는 범위와 맞아야 한다. 단지 schema에 format: uuid를 적었다고 모든 validator가 자동으로 거부하는 것은 아니므로 사용하는 도구의 동작을 확인한다.

optional, nullable, readOnly 구분

세 개념을 섞으면 client type이 실제 계약과 달라진다.

표현 의미
required에 없음 속성 자체가 없을 수 있음
null 허용 type 속성이 존재하며 값이 null일 수 있음
readOnly: true 응답에 사용하고 요청에서는 받지 않는 속성
writeOnly: true 요청에는 사용하지만 응답에 노출하지 않는 속성

다음 세 JSON은 서로 다른 상태다.

{}
{ "avatarUrl": null }
{ "avatarUrl": "https://cdn.example.test/avatar.png" }

PATCH에서는 “필드 누락은 변경하지 않음”, “null은 값 제거”처럼 의미가 달라질 수 있다. schema와 설명에서 이 차이를 명시한다.

비밀번호를 request schema에 넣은 뒤 같은 User schema를 응답에도 재사용하면 writeOnly 설정 누락으로 문서와 generated model에 비밀번호가 노출될 수 있다. 요청·응답 목적이 다르면 억지로 하나의 schema를 재사용하지 않고 CreateUserRequest, UserResponse로 분리하는 편이 명확하다.

성공 응답만 문서화하면 부족하다

client 코드는 실패에서 더 많은 분기를 한다. 공통 오류 형식을 정하면 status와 업무 code를 함께 사용할 수 있다.

components:
  schemas:
    Problem:
      type: object
      required: [status, code, message, traceId]
      properties:
        status:
          type: integer
        code:
          type: string
        message:
          type: string
        traceId:
          type: string

  responses:
    UserNotFound:
      description: 사용자를 찾을 수 없음
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
          example:
            status: 404
            code: USER_NOT_FOUND
            message: 사용자를 찾을 수 없습니다.
            traceId: trace-example-42

400, 401, 403, 404를 무조건 default 하나로 뭉치면 generated client가 상태별 대응을 타입으로 알기 어렵다. 주요 실패를 명시하고 실제 middleware 오류 응답도 같은 schema를 쓰게 한다.

Schema First와 Code First

Schema First

OpenAPI를 먼저 리뷰하고 server와 client를 생성하거나 구현한다. 외부 팀이 계약을 합의하고 병렬 개발하기 좋다. 반면 schema와 server 구현 사이를 연결하는 도구가 약하면 이중 작업이 된다.

Code First

route와 DTO annotation 또는 type에서 OpenAPI를 생성한다. 구현과 가까워 drift를 줄이기 쉽지만 내부 클래스 구조가 외부 계약으로 새어 나오고, annotation으로 표현하기 어려운 의미가 생길 수 있다.

기준 Schema First Code First
계약 리뷰 시점 구현 전 가능 코드 변경과 함께
외부 소비자 협업 강점 생성 artifact 공유 필요
구현 중복 adapter 필요할 수 있음 framework 결합 가능
내부 모델 노출 위험 설계로 통제 자동 생성 시 주의

어느 방식이든 repository에서 canonical source를 하나 정한다. 수동으로 수정한 YAML과 code-generated YAML을 둘 다 진실로 취급하면 다시 drift가 생긴다.

런타임 검증과 타입 생성 연결하기

TypeScript type은 compile 이후 사라지므로 외부 HTTP 입력을 검증하지 않는다. OpenAPI에서 validator를 생성하거나 같은 runtime schema에서 OpenAPI와 type을 만들 수 있다.

async function createUser(request, response) {
  const input = createUserRequestSchema.parse(request.body);
  const result = await userService.create(input);
  const output = userResponseSchema.parse(presentUser(result));
  return response.status(201).json(output);
}

모든 응답을 production에서 검증하면 비용이 클 수 있다. 중요한 endpoint에는 항상 적용하고, 나머지는 test·staging·표본 검증으로 운영할 수 있다. 검증 실패를 단순 500으로 숨기지 말고 contract violation 지표와 trace를 남긴다. 응답 내용 전체를 로그에 남겨 개인정보가 유출되지 않게 한다.

generated client도 빌드하고 타입 검사한다.

const user = await api.getUser({ userId });

// schema가 avatarUrl을 nullable로 정의하면 client도 처리해야 한다.
const avatar = user.avatarUrl ?? DEFAULT_AVATAR_URL;

생성 결과를 repository에 commit할지 CI artifact로만 만들지는 팀 workflow에 맞추되, schema 변경 후 regeneration 누락을 CI가 잡아야 한다.

CI에서 breaking change 찾기

PR의 OpenAPI와 배포된 기준 schema를 비교해 다음 변경을 탐지할 수 있다.

steps:
  - name: Generate OpenAPI
    run: npm run openapi:generate
  - name: Validate document
    run: npm run openapi:lint
  - name: Check breaking changes
    run: npm run openapi:diff -- --base origin/main
  - name: Build generated client
    run: npm run client:generate && npm run client:typecheck

도구 결과가 모든 breaking change를 아는 것은 아니다. 필드 단위나 정렬 의미 변경처럼 schema가 같아도 계약이 깨질 수 있다. diff는 리뷰 gate의 보조 수단이고 변경 설명과 contract test가 함께 필요하다.

새 major version의 의도된 breaking change는 무조건 검사에서 제외하기보다 승인된 변경 목록과 migration 문서를 요구한다. 관련 정책은 API 버전 관리에 URL과 헤더를 사용하는 방법과 연결된다.

실제 응답과 스키마 drift 찾기

CI에서 생성 문서가 맞아도 production의 gateway, middleware, feature flag가 다른 응답을 만들 수 있다. 통합 테스트가 실제 HTTP 응답을 schema로 검증한다.

it("GET /users/{id} 응답은 공개 계약과 일치한다", async () => {
  const response = await request(app)
    .get(`/users/${seededUser.id}`)
    .set("Authorization", `Bearer ${testAccessToken}`)
    .expect(200);

  expect(() => validateOpenApiResponse({
    operationId: "getUser",
    status: 200,
    body: response.body,
  })).not.toThrow();
});

consumer-driven contract를 더하면 중요한 client가 실제로 사용하는 필드를 알 수 있다. 하지만 등록되지 않은 외부 client를 보호하지 못하므로 OpenAPI의 공개 호환성 약속을 대신하지 않는다.

공개 모델과 내부 모델을 분리한다

ORM entity를 바로 schema로 노출하면 password_hash, 내부 상태, soft delete 시각 같은 필드가 실수로 응답에 포함될 수 있다. DB 컬럼 이름 변경이 API breaking change가 되기도 한다.

type UserRow = {
  id: Buffer;
  email_normalized: string;
  password_hash: string;
  deleted_at: Date | null;
};

type UserResponse = {
  id: string;
  displayName: string;
  avatarUrl: string | null;
  createdAt: string;
};

명시적인 presenter가 내부 데이터를 공개 계약으로 변환한다. 반복 코드가 생기더라도 보안 경계와 버전 adapter가 눈에 보이는 장점이 있다.

운영 점검 목록

OpenAPI를 계약으로 유지하기

  • canonical source가 schema와 코드 중 하나로 정해져 있는가?
  • optional, nullable, readOnly, writeOnly 의미가 정확한가?
  • 주요 오류 status와 body가 문서화되어 있는가?
  • request와 중요 response를 runtime 또는 통합 테스트에서 검증하는가?
  • generated client를 실제로 build·typecheck하는가?
  • CI가 lint와 breaking diff를 수행하는가?
  • 내부 ORM entity가 공개 schema로 직접 노출되지 않는가?
  • production contract violation을 개인정보 없이 관측하는가?

스키마에서 클라이언트 타입과 문서를 생성하고 CI에서 breaking change를 검사한다. 서버의 런타임 검증도 같은 스키마와 연결한다.

결론

OpenAPI는 문서 화면을 만들기 위한 YAML이 아니라 server와 client가 공유하는 실행 가능한 계약이어야 한다. canonical source를 하나로 정하고 요청·오류·nullable 의미를 완전히 기술한다. runtime 또는 통합 응답 검증, generated client build, CI breaking diff를 연결하고 내부 DB 모델은 adapter 뒤에 숨겨야 문서와 실제 API가 함께 움직인다.

관련 노트